Skip to content

feat(service-analytics)!: refuse an aggregate a datetime measure's field type cannot carry, and reconcile the storage-form annotations to one measured statement - #16778

Draft
os-trump wants to merge 6 commits into
mainfrom
claude/issue-16737-avg-datetime-measure
Draft

feat(service-analytics)!: refuse an aggregate a datetime measure's field type cannot carry, and reconcile the storage-form annotations to one measured statement#16778
os-trump wants to merge 6 commits into
mainfrom
claude/issue-16737-avg-datetime-measure

Conversation

@os-trump

@os-trump os-trump commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16737

Also implements the compile leg described on #16099 — that card stays open and its
disposition is the PM's; nothing here is a verdict about it.

Patch round on the contract review (PR comment 5580295870, CONTRACT_REVIEW_TIER,
verdict CHANGES REQUIRED). F1–F5 are addressed in commit 181d3cc8a; what changed and
why is the last section of this body. This body has been corrected in place — the
passages the review falsified are rewritten, not annotated.

1. The storage reality, established first — and it is a THIRD answer

The card said the package states two incompatible answers. Measured on origin/main,
neither is current. A SQLite Field.datetime column has ONE storage form:
canonical UTC TEXT, YYYY-MM-DDTHH:MM:SS.sssZ (#3912/#3928).

How it was measured, not recalled:

  • SqlDriver.storageDatetimeValue canonicalises on the write path, and
    sql-driver-datetime-canonical-storage.test.ts pins every accepted input shape
    folding onto the same stored string — a Date, ISO …Z, ISO with an offset, a naive
    wall clock, an epoch number, an epoch string, a bare calendar day. The NOW() column
    default writes the same strftime('%Y-%m-%dT%H:%M:%fZ', 'now') bytes.
  • The INTEGER epoch survives only in a database written before the convention.
    initObjects runs backfillCanonicalDatetimes at schema sync;
    needsLegacyDatetimeRepair is the one predicate for "might this column still hold a
    pre-canonical value". Two cases keep it true: a table not yet backfilled, and an
    external / unmanaged object (registerExternalObject never marks its datetime columns
    canonical).
  • Postgres and MySQL never enter the question: the DDL gives them timestamptz /
    DATETIME(3), one on-disk shape by construction.

Re-driven live, in this session, on a fresh SQLite:

select typeof(dt), dt from t limit 1   => {"t":"text","v":"2026-05-19T00:00:00.000Z"}
select avg(dt) from t                  => 2025.5      <- the average YEAR
select sum(dt) from t                  => 4051
select avg(d)  from t   -- a `date`    => 2025.5
select avg(tm) from t   -- a `time`    => 13          <- the average HOUR
select avg(n)  from t   -- a `number`  => 15          <- the control: correct
select min(dt), max(dt) from t         => the two real instants

So the filer's text|2026-05-19T00:00:00.000Z reading is exactly what the current
driver produces. ⭐ The mixed INTEGER/TEXT column is the transitional state, and the
flat "a SQLite Field.datetime IS an INTEGER epoch" has been wrong since #3912.

2. coerceTemporal — the thing triage named as unmeasured

It is correct for the mixed-storage reality, and it is correct for the canonical one.
Its docblocks were the stale part.

  • The comparand half (NativeSQLStrategy.coerceTemporalctx.coerceTemporalFilterValue
    SqlDriver.temporalFilterValuecoerceFilterValue) canonicalises to the same
    function the write path uses, so an ISO or bare-day comparand becomes the stored form.
    Pinned in sql-driver-temporal-dialect.test.ts and sql-driver-analytics-datetime.test.ts.
  • The column half (temporalColumntemporalFilterColumnSql) emits the repair CASE
    only while needsLegacyDatetimeRepair holds, and the bare column otherwise —
    pinned by sql-driver-datetime-canonical-storage.test.ts
    (expect(driver.temporalFilterColumnSql('evt','at','"at"')).toBe('"at"') after backfill).

No code change was needed there. A test case was added on the analytics side
(native-sql-datetime-filter.test.ts) driving the strategy against a hook that returns
today's canonical UTC text, beside the epoch hook the file was written with — so the
suite covers the live storage form as well as the legacy one.

3. The annotations reconciled — seven sites, not the one the filer quoted

The card named four. A sweep for the claim found seven in source plus two test
narratives, all making the same stale statement. All are now consistent, and the fact is
stated once:

site was now
analytics-service.ts coerceTemporalFilterValue "SQLite Field.datetime → epoch ms" the single canonical statement — measured, with the legacy/external tiers named
analytics-service.ts coerceTemporalFilterColumn (:493) "holds BOTH storage forms … at once" links to the block above; states that the column half is now CONDITIONAL
plugin.ts (:640) "becomes epoch ms on SQLite" links; no restatement
plugin.ts (:662) "holds BOTH storage forms" links; names the mixed form as transitional
native-sql-strategy.ts temporalColumn (:963) "carries an INTEGER epoch and ISO TEXT at the SAME time" links; keeps what is still true of the method
native-sql-strategy.ts buildFilterClause (:1190) "converted to its INTEGER epoch storage form" links
objectql-strategy.ts dateRangeBounds (:1669) "a SQLite Field.datetime IS an INTEGER epoch (#2034)" links; keeps the real reason NativeSQLStrategy needs the coercion
__tests__/native-sql-datetime-filter.test.ts narrative asserted the epoch as current narrative corrected; epoch fixture KEPT and re-labelled (it is the only hook that changes value AND JS type, which is what makes "bound verbatim" decidable)
__tests__/native-sql-datetime-filter-column.test.ts narrative asserted the mixed column as steady state narrative corrected; EPOCH_MS(...) re-labelled as a marker, not a claim about emitted SQL

4. Refusal, not a definition — and where it stops

Refusal. A definition would have to pick one of two dialect answers and ship it as
the meaning of a number nobody can check. The refusal moves the failure to the person
writing the dashboard, which is the PM direction and the card's own danger analysis.

It is not a new rule. The contract already exists: AGGREGATE_FIELD_TYPE_COMPATIBILITY
in @objectstack/spec (#16353, landed as 6ba0db4e0), declared under the director
ruling of decision batch #59. Until this PR it had zero consumers. compileDataset
now reads the shipped predicate and refuses with DATASET_INVALID / 400 before any
query is built, using the declared type the host already supplies through
AnalyticsServiceConfig.sourceFieldMeta (threaded as a new optional
DatasetCompileOptions.declaredFieldType probe, tiered "cannot answer, do not block").

⚠️ The compile leg lands SCOPED to temporal source fields — the dispatch's stop condition, honoured

The dispatch said: stop and report if refusing would break a use that currently works
and is legitimate
. Executing every row of the table did exactly that when this branch
was cut. Where the two families stand now:

⇒ The gate judges the temporal class only (TEMPORAL_SOURCE_FIELD_TYPES, this
package's own shipped statement of it). The verdict is still the table's — nothing is
restated, and min/max over a temporal field stay accepted because the table accepts
them. The temporal rows carry no counter-evidence: measured on both dialects, no shipped
dataset in this repo pairs them, and there is no reading on which the mean of a set of
instants is a duration. The full-table leg stays with #16099, now waiting on #16785's
amendment landing rather than on two unruled collisions. The table's sum × percent
row is likewise not executed by this leg.

5. derived is covered by construction

A derived measure's of operands are base measures of the same dataset, and the executor
expands a selected derived measure into them before querying. Both are downstream of the
compile loop, so a dataset carrying a refused base measure never finishes compiling and no
derived op can be handed its output — including when the selection names only the
derived measure
, which is the filer's exact shape. Pinned as two cases, one of them
selecting only cycle_delta.

6. Second dialect — measured on real PostgreSQL 16.13

select avg(submitted_at) from t16737;   -- timestamptz
  ERROR:  function avg(timestamp with time zone) does not exist   -- SQLSTATE 42883
select sum(submitted_at) from t16737;   -- same
select avg('2026-05-19'::date);         -- ERROR: function avg(date) does not exist
select min(submitted_at), max(submitted_at) from t16737;
  2025-01-19 00:00:00+00 | 2026-05-19 00:00:00+00                 -- correct

Which half is dialect-specific: the SILENT half is SQLite's. SQLite has no temporal
type, so the stored text is coerced to a number by its leading digits and the call
succeeds. Postgres has a real timestamptz and no avg over it, so it refuses loudly.
The MEANINGLESS half is not dialect-specific — there is no backend on which the mean
of a set of instants is a duration; Postgres simply says so out loud.

⚠️ One asymmetry worth recording: select avg('12:00:00'::time) succeeds on Postgres
and returns a clock time, while SQLite silently answers 13 (the average hour) for the
same data. So avg over a Field.time is a genuine per-dialect split. The ruled table
refuses it (time appears only in the min/max rows), and that ruling is executed here
rather than re-litigated.

⚠️ The two halves are not evidenced alike, and the ledger now says so (review F3). The
SQLite half is PINNED by a live sql.js suite in the new test file. The Postgres 42883
half was measured in-session on a real PostgreSQL 16.13 started in this container and is
pinned by no test — the live PG conformance job carries no cell for it. Nothing
depends on it: the refusal is decided from declared metadata before a driver is reached.

7. Negative controls — this card is aggregation only

All pinned in aggregate-datetime-measure-refusal.test.ts:

  • avg over a number measure, and over a currency measure — still work end to end,
    one statement emitted, AVG in the SQL.
  • A datetime used as a dimension — month grouping, and a dateRange window — both
    reach the engine unchanged (the window asserted present in the engine call).
  • min / max over a datetime — still accepted, still return real instants.
  • count over a datetime — still accepted.
  • A derived measure over numeric operands — untouched.
  • The scope boundary, held without pinning a verdict under ruling (review F4). Two
    cases: min over a text field still compiles here whatever the table says about
    that pair
    — the direct isAggregateCompatibleWithFieldType('min','text') === false
    assertion is gone, because [Decision] The ruled aggregate × field-type table cannot be executed in full: its string rows contradict #15768's typing and its boolean rows contradict ruling #11152 — enforce, amend, or leave partly decorative? #16785 C amends exactly that row and a test of this card
    must not be what stands in the way of it. Refutability is carried by a second case on
    sum over text, a row no ruling is moving, plus a non-vacuity assertion in both that
    SQL actually reached the driver — a widened gate refuses before any SQL, so sqls
    would be empty and both cases go red.

Tests

pnpm --filter @objectstack/service-analytics test
  Test Files  97 passed (97)         Tests  2188 passed (2188)
pnpm --filter @objectstack/spec test
  Test Files  465 passed (465)       Tests  12966 passed (12966)
pnpm --workspace-concurrency=2 --filter @objectstack/service-analytics --filter @objectstack/spec typecheck
  -> exit 0 (both)
  check:test-typecheck: OK — 54 file(s) / 261 error(s) / 145 pinned signature(s) held
pnpm lint            -- the repo-wide `eslint . --no-inline-config`
  -> exit 0, no findings, at HEAD 181d3cc8a
  population read from eslint's own config, not guessed: 6369 files selected for `eslint .`
  (ESLint API, `allowInlineConfig: false`), 0 errors / 0 warnings. Not narrowed.

The dependency closure was built before any of the above
(pnpm --filter '@objectstack/service-analytics^...' build), and the rebuilt
@objectstack/spec dist was read back to prove the merged table is what the tests see:
AGGREGATE_FIELD_TYPE_COMPATIBILITY.avg = number,currency,percent,rating,slider,progress,summary,boolean,toggle
#16750's rows present, so no verdict below was taken against a stale build.

Dissolution verification — direction predicted before running, mutation proved on disk

Deleting the assertAggregateFieldTypeCompatible call from the measure loop, re-run on
the patched head:

HEAD blob          = 2f7b2c6faf8a623f18d5fd12e90bd298cac132c8
worktree (pre)     = 2f7b2c6faf8a623f18d5fd12e90bd298cac132c8   (identical)
anchor occurrences BEFORE = 1  AFTER = 0    injected marker = 1
worktree (mutated) = aea7e9f38815a59e31bf1c725134f57e14336598   (changed: reached disk)
ABLATED RUN EXIT = 1 — Tests  7 failed | 17 passed (24)
restore: blob back to 2f7b2c6f… AND `git diff HEAD` for the target EMPTY

The 7 red are exactly the refusal cases (both derived cases among them) plus the
scope-boundary suite's "every temporal member IS judged"
— so the boundary suite made
neutral under F4 is still refutable, which is the point of the F4 change. The 17 green are
the negative controls, the tiering tiers, the two scope-boundary compile cases, the
contract-table assertions and the live SQLite measurement suite. No case can pass
vacuously: each refusal case asserts the envelope AND that no SQL was emitted, and with
the gate gone the compile succeeds and SQL IS emitted. Restored under a
trap … EXIT INT TERM with git checkout HEAD -- ABSPATH (not a bare checkout), proved
by blob equality plus an empty git diff HEAD, never by an exit code.

⛔ No rebuild leg was needed for the ablation and none is claimed: the mutated module and
the suite resolve through the same package's src (the test imports ../analytics-service.js,
and service-analytics has no vitest alias). The ablation's own red is that proof — a
dist-mediated subject would have stayed green without a rebuild. The cross-package half
that IS dist-mediated (@objectstack/spec, a KNOWN_UNALIASED_TEST_IMPORTS pair) was
rebuilt and property-read first, above.

Gates

node scripts/pm/dispatch-gates.mjs --commands derived the family on the merged tree
(commit 181d3cc8a); every command was run and reconciled:

✓ dispatch-gates --ran: 79 derived famil(ies) accounted for — 79 run, 0 NOT-MEASURED.
   76 exit 0 · 3 exit 3 (PREREQUISITE NOT MET), one of which was then satisfied and re-run
  • pnpm --filter @objectstack/lint check:doc-formula-expressions — first run exit 3
    (@objectstack/lint not built). Prerequisite satisfied (pnpm --filter '@objectstack/lint...' build) and re-run: exit 0.
  • pnpm check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET (needs a whole-farm
    pnpm build; 70+ packages without dist). NOT MEASURED, declared to CI.
  • pnpm check:type-check-debt — exit 3, PREREQUISITE NOT MET (--re-measure needs the
    built closure of 23 workspace deps). NOT MEASURED, declared to CI. Its first half,
    check:type-check-coverage, passed.

Two families the derivation does not print, run explicitly:

ADR-0087 and the ledger, at the patched head:

node scripts/check-adr-0087-registration.mjs --base origin/main   -> exit 0
  ✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.
    [BREAKING+bang] registered dataset-measure-aggregate-field-type-refused (new here)
node scripts/check-changeset-no-major.mjs --base origin/main      -> exit 0 (no `major`)
pnpm --filter @objectstack/spec check:migration-registry          -> exit 0
  ✓ src/migrations/registry.ts is current (192 semantic, 165 retired-key, 116 retired-def)
pnpm --filter @objectstack/spec check:spec-changes                -> exit 0 (up to date)
pnpm --filter @objectstack/spec check:upgrade-guide               -> exit 0 (up to date)
pnpm --filter @objectstack/spec check:authorable-surface          -> exit 0
pnpm check:nul-bytes -> exit 0 (8336 text files, no raw control bytes)

registry.ts is generated, never hand-edited: it was written by
pnpm --filter @objectstack/spec gen:migration-registry, two consecutive runs produce a
byte-identical file (git hash-object = 4c387065… both times, diff empty), and the
resulting diff is confined to this entry's own block inside step18. The working tree is
clean after the whole gate sweep (git status --porcelain empty), so no gate wrote a
baseline behind the run.

Clause-② and the changeset

Clause-②: yes. The dispatch's provisional read was no; the diff overturns it, on
both the mechanical floor and the gate:

node scripts/pm/check-widening-tells.mjs --declaration no  -> exit 4
  T2 packages/spec/src/migrations/registry.ts — a new member of a closed set
node scripts/pm/check-widening-tells.mjs --declaration yes -> exit 0

The mechanical floor also applies on its own: DatasetCompileOptions.declaredFieldType is
a new key on a published exported type (packages/services/service-analytics/src/index.ts
re-exports DatasetCompileOptions). ⚠️ needs:contract-review was removed from this
PR when the review concluded; re-hanging it on the patched head is the PM seat's act, not
this branch's.

Changeset: minor for both packages, declared BREAKING with a ! title. It is an
accept-set narrowing on a published authoring surface, which is breaking — and #16353's own
changeset says in writing that the consumer legs "carry the breaking declaration, its
migration prescription and the ADR-0087 disposition". minor rather than major is the
repo's launch-window convention for accept-set narrowings, stated verbatim in the protocol
18 step's header ("The enforcement itself ships earlier on the 17.x line … this step is
where migrate meta users are told"). ⛔ Not patch: nothing here is "just a bug fix" —
authoring shapes that parsed and ran now fail. The FROM → TO table is in the changeset body,
and it now describes only what this leg withdraws.

Patch round — what the contract review asked for, and what was done

finding done
F1 (blocking) — the breaking declaration described the pre-scoping full-table gate changeset FROM/TO: both percent rows dropped, and the surviving avg row widened to name all three temporal members it does refuse. A new scope section states the temporal scope outright, names #16785 (ruled C) for the string rows, #16685/#16750 for the boolean rows, and says sum × percent is not executed. Ledger surface: scoped to the temporal class, "sum over a percent" removed. Ledger replacement: the "avg for a percent" prescription dropped with the surface rows it belonged to. Ledger acceptanceCriteria: qualified to a measure over a date / datetime / time field, and it now says outright that a field of any other class is neither refused nor certified here. registry.ts REGENERATED (idempotence + entry-block-only diff proved above).
F2 — stale boolean narrative in three places main merged (31888c210, brings ed7243d52 = #16750; git merge-base --is-ancestor ed7243d52 HEAD exit 0). Reworded in dataset-compiler.ts's scope docblock, the refusal suite's module header, and measure-result-type.ts (whose boolean paragraph still referred a missing refusal onward — the table now ACCEPTS the pair, so none is owed). All three also record #16785 C for the string rows.
F3 (optional) — Postgres premise asserted, not pinned done. The ledger reason now separates the two halves explicitly: SQLite PINNED by the live sql.js suite, Postgres 42883 MEASURED IN-SESSION and pinned by nothing. Chose the "mark it" option over adding a PG conformance cell — the second would widen this card into the live-PG job, which the review left open as either/or.
F4 — the boundary test pinned the row #16785 C amends done, and deliberately not by the tautological spelling the review offered as an alternative: a boundary test made neutral must still be able to fail. See §7's last bullet and the ablation block.
F5 (optional) — uncovered faces recorded in the body but not the changeset done. The changeset's "What is deliberately untouched" now names /analytics/query and any compileDataset caller wiring no declaredFieldType probe.
F6 — CI tree predated #16750 resolved by F2's merge; the tree measured above contains it.

One correction made beyond the review's list, in the same class as F1: the changeset said
"four contradictory annotations", which was the card's count. The sweep reconciled seven
source sites plus two test narratives (§3, and the review's own item 4 counts seven). The
changeset now says seven.

验收备注

Reported here for the PM, not filed as issues:

  1. check:migration-registry is absent from dispatch-gates --commands even on a
    diff that regenerates packages/spec/src/migrations/registry.ts. Same shape as
    check:route-envelope/dispatch-gates: a whole-tree-walk gate whose workflow names: lists only its CURRENT members is placed Silent, so it is never derived for the card that adds a new member — measured on check:route-envelope / PR #16730 #16828: a family that applies, derives as nothing, and is only
    caught because a human review named it. Both were run explicitly here.
  2. No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099 is still labelled pm:blocked with Blocked-by: #16353, and spec: declare the aggregate × field-type compatibility matrix (AggregationFunction × FieldType) that dataset measures are refused against (spec half of #16099) #16353 has
    landed
    (6ba0db4e0, PR feat(spec): declare the aggregate × field-type compatibility matrix (AggregationFunction × FieldType) dataset measures are refused against (#16353) #16684). The blocker is stale; the label is the PM's to move.
    ⛔ Untouched by this branch.
  3. /analytics/query (the non-dataset face) is not covered by this leg. An
    auto-inferred submitted_at_avg measure on a Cube still reaches the driver — the
    ruling names two legs (lint + compile) and neither is that face. Now also stated in the
    changeset (F5). Observation, not a defect this PR introduces.
  4. The authoring-time lint leg has not landed; packages/lint still carries no rule
    pairing a measure's aggregate with its field type. That is No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099's sibling devx card.
  5. avg over a Field.time is a real per-dialect split (Postgres answers a clock
    time; SQLite answers the average hour). The table refuses it and this PR executes that;
    worth a maintainer eye if anyone was relying on the Postgres behaviour.
  6. The full-table leg's remaining blocker is now one ruling, not two. [Decision] Two maintainer rulings collide on boolean aggregates — batch #59's "every other pair refused" would refuse avg(flag), which ruling #11152 pins on six backends as having no per-aggregate exception #16685/feat(spec): accept boolean / toggle for sum / avg / min / max in the aggregate × field-type table (#16685) #16750
    settled the boolean rows as ACCEPT; [Decision] The ruled aggregate × field-type table cannot be executed in full: its string rows contradict #15768's typing and its boolean rows contradict ruling #11152 — enforce, amend, or leave partly decorative? #16785 ruled C on the string rows but that
    amendment has not landed in @objectstack/spec yet — isAggregateCompatibleWithFieldType('min','text')
    still answers false on this tree. No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099's leg widens once it does.

None of these were filed: a duplicate-search would be needed for each, and items 2 and 6
are dispositions on existing cards rather than new work.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37


Generated by Claude Code

…cannot carry

WIP — compile-leg refusal + the four reconciled datetime-storage annotations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…rce fields

The full table refuses `min`/`max` over the string classes and the boolean rows,
both of which this platform answers on purpose and pins with tests (#15768,
maintainer ruling #11152). Executing those is a product judgement that belongs to
#16099; the temporal rows carry no such collision and are the ones this card is
about. Re-points the two `measure-result-type.test.ts` fixture measures that
aggregated a datetime column, and corrects the module header that recorded the
missing refusal as an open finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec, touching 12 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/measure-result-type.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via ObjectQLStrategy (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/measure-result-type.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c930f859714de408ba0221f435ff957ed9e64759packageMentionDocs.

Which tree this was computed on

This run read content/docs from ec4a03b5619a2a8415adf00e3a62c7e5806f677d — the merge of head 181d3cc8a7727c3c80f7cef470ecf0339b46924b into base c930f859714de408ba0221f435ff957ed9e64759, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ec4a03b5619a2a8415adf00e3a62c7e5806f677d && git checkout ec4a03b5619a2a8415adf00e3a62c7e5806f677d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c930f859714de408ba0221f435ff957ed9e64759 181d3cc8a7727c3c80f7cef470ecf0339b46924b && git checkout -B drift-repro c930f859714de408ba0221f435ff957ed9e64759 && git merge --no-ff 181d3cc8a7727c3c80f7cef470ecf0339b46924b

node scripts/docs-audit/affected-docs.mjs --json c930f859714de408ba0221f435ff957ed9e64759

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c930f859714de408ba0221f435ff957ed9e64759 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16778 @ 80ec9f2

Verdict: CHANGES REQUIRED — the code implements the ruling exactly; the breaking declaration (changeset + ADR-0087 ledger entry) declares a refusal the code does not perform (F1). Text-only fix, no behaviour change needed.

Ruling implemented: YES. The compile gate judges only fields in TEMPORAL_SOURCE_FIELD_TYPES (date / datetime / time); string and boolean fields are never handed to the predicate. Verified in code, not from the body: dataset-compiler.ts assertAggregateFieldTypeCompatible returns before calling isAggregateCompatibleWithFieldType unless TEMPORAL_SOURCE_FIELD_TYPES.has(fieldType); the scope-boundary test pins min × text still compiling. Nothing enforces the string rows (would pre-empt #16785 C) or the boolean rows (would contradict #11152 / #16685).

Rulings, quoted

#16785, maintainer via director seat, comment 5580219196 (2026-09-08 06:16Z):

Ruled: C, with one correction to the card's framing: the boolean half is already settled — #16685 was ruled A and PR #16750 (merged 2026-09-08T05:12Z) added boolean / toggle to sum / avg / min / max; only the string rows remain. […] The table therefore accepts min / max for the string-typed field types #15768 types 'string'; the temporal refusals stay as PR #16778 executes them. […] @objectstack/spec minor; ⛔ no ADR text moves.

#16737, PM seat, comment 5579707565: 「问题一:已裁的兼容表无法整表执行 —— 裁 A(维持温度类范围)」 / 「A = 本 PR 已做的:只执行温度类,把 string 与 boolean 两组另行送裁。」

⇒ Both rulings converge on: this PR enforces the temporal rows only. It does.

Verification (independent, against refs/review/16778 vs merge-base 8b37a0973)

  1. Scope of enforcement. Refused at compile time: avg / sum over date, datetime, time (any aggregate the table's row lacks for a temporal field). Accepted and pinned: count / min / max over datetime, avg over number / currency, datetime as a grouping dimension and as a dateRange window, derived over numeric operands, min over text. Boolean fields: never judged (not in the temporal set). Consistent with both rulings.
  2. Files (13, none governed). .changeset/dataset-measure-aggregate-field-type-refused.md (A); service-analytics/src/: dataset-compiler.ts, analytics-service.ts, plugin.ts, measure-result-type.ts, strategies/native-sql-strategy.ts, strategies/objectql-strategy.ts (M); __tests__/aggregate-datetime-measure-refusal.test.ts (A), __tests__/measure-result-type.test.ts, __tests__/native-sql-datetime-filter.test.ts, __tests__/native-sql-datetime-filter-column.test.ts (M); packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts (A), packages/spec/src/migrations/registry.ts (M, the generated concatenation). Checked against GOVERNED_SURFACES (docs/adr/, .claude/, skills/, AGENTS.md, CLAUDE.md) and CODEOWNERS (docs/adr/, CODEOWNERS, check-governed-merges.mjs): no governed path. content/docs/releases/ untouched.
  3. The refusal. Compile-time, in compileDataset's measure loop, after assertDeclared; throws datasetInvalidError(...)DATASET_INVALID / 400. The code is pre-existing and registered: packages/spec/src/api/error-code-ledger.zod.ts:219,898 (ADR-0112 D3 ledger, RegisteredErrorCode-typed at dataset-refusal.ts:124). No new code introduced; packages/runtime/src/dispatcher-error-vocabulary.ts is not the relevant ledger for this service-level code. Host wiring: analytics-service.ts threads declaredFieldType from the existing sourceFieldMeta(...)?.type. Measured premise: the SQLite half (typeof = text, avg = 2025.5, min/max = real instants, numeric control = 20) is pinned by a live sql.js suite in the new test file (sql.js is a declared devDependency, already used by five sibling suites). The Postgres half (42883) is asserted only — body, compiler docblock, ledger reason, test header — no test drives it and the live PG conformance job is untouched (F3).
  4. Annotation reconciliation — no behaviour hides in it. Seven source sites moved, all comments/docblocks: analytics-service.ts coerceTemporalFilterValue (new single statement) and coerceTemporalFilterColumn; plugin.ts ×2 (the two hook bridges); native-sql-strategy.ts temporalColumn and buildFilterClause; objectql-strategy.ts dateRangeBounds; plus measure-result-type.ts's module TSDoc. Diff hunks in those files are comment-only except the declaredFieldType: line in analytics-service.ts (the wiring, part of the refusal half). Test edits: two narratives reworded, one additive case (canonicalTextHook, binds '2025-06-18T00:00:00.000Z'), and measure-result-type.test.ts's summed_touches / avg_touch re-pointed from last_update_at to estimate_hours (necessary: the old fixture would now be refused; the pin — sum/avg say number — is unchanged).
  5. Changeset. @objectstack/service-analytics: minor, @objectstack/spec: minor — correct per batch [WIP] Add query enhancements and advanced validation features #35 WHICH LEVEL (accept-set narrowing on a published surface, launch-window minor; spec grows a registry entry). **BREAKING** banner present. <!-- adr-0087: registered dataset-measure-aggregate-field-type-refused --> present; ran node scripts/check-adr-0087-registration.mjs --base origin/main --head refs/review/16778 → exit 0, [BREAKING+bang] registered … (new here); check-changeset-no-major.mjs → no major. FROM/TO table present — but two of its rows and the ledger entry's surface / acceptanceCriteria describe the pre-scoping full-table gate (F1).
  6. Tests. Revert-sensitive: 7 refusal cases assert code + status + message + sqls === [], so a removed gate turns them red (SQL is emitted). Negative controls present for numeric avg (number, currency), datetime count/min/max, datetime dimension (group + dateRange), numeric derived, and the three stand-down tiers. derived-only selection pinned (the filer's shape). No .skip / .only / .todo in the diff. tsconfig.json includes src and excludes only node_modules/dist, so __tests__ is typechecked.
  7. CI @ 80ec9f2: 38 check runs — 32 success, 6 skipped, 0 failure; mergeable_state: clean; draft. Head is 11 commits behind main; ed7243d52 (feat(spec): accept boolean / toggle for sum / avg / min / max in the aggregate × field-type table (#16685) #16750, boolean rows) is not in the PR's ancestry (merge-base --is-ancestor → no), and CI ran on a merge into 941232040, which also predates feat(spec): accept boolean / toggle for sum / avg / min / max in the aggregate × field-type table (#16685) #16750. Predicted post-merge: no red (the gate never judges boolean fields; the accepted-set message assertion iterates the live table rather than a hard-coded list).

Findings

Not done by this seat

No approval, no request-changes, no label, no edit, no merge action. Throwaway ref refs/review/16778 deleted after review.


Generated by Claude Code

Brings #16750 (boolean/toggle rows added to sum/avg/min/max in the
aggregate x field-type compatibility table), which settles the boolean
half of the collision this branch reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…class it actually withdraws

The compile leg was scoped to TEMPORAL source fields in 80ec9f2, but the
changeset and the ADR-0087 ledger entry still described the pre-scoping
full-table gate. A breaking-change record that overstates what changed tells
every consumer reading the release notes that behaviour was withdrawn which
was not.

F1 — the breaking declaration:
- changeset FROM/TO: the two `percent` rows dropped (`sum` x `percent` is a
  table row this leg does not execute; `avg` x `percent` was never a
  migration at all), and the surviving `avg` row widened to name all three
  temporal members it does refuse.
- changeset: a new scope section states the temporal scope outright, and that
  the string rows sit under #16785 (ruled C - the table is to be AMENDED to
  accept them), the boolean rows were settled as ACCEPT by #16685 / #16750,
  and `sum` x `percent` is not executed here.
- ledger `surface`: scoped to the temporal class; "sum over a percent" removed.
- ledger `replacement`: the "`avg` for a `percent`" prescription dropped with
  the surface rows it belonged to.
- ledger `acceptanceCriteria`: qualified to a measure over a
  `date` / `datetime` / `time` field, and says outright that a field of any
  other class is neither refused nor certified by this leg.
- `registry.ts` REGENERATED with `pnpm --filter @objectstack/spec
  gen:migration-registry`, never hand-edited; two consecutive runs are
  byte-identical and the diff is confined to this entry's block.
- also corrected: the changeset said "four contradictory annotations"; the
  sweep reconciled seven source sites plus two test narratives.

F2 — the boolean collision is settled, so stop narrating it as live. #16685
was ruled A and #16750 added `boolean` / `toggle` to the four arithmetic /
order rows, so the table ACCEPTS them. Reworded in `dataset-compiler.ts`'s
scope docblock, the refusal suite's module header, and `measure-result-type.ts`
(whose boolean paragraph still referred a missing refusal onward). All three
now also record #16785 C for the string rows.

F3 — the ledger `reason` presented both dialect halves as measured alike. The
SQLite half is pinned by a live `sql.js` suite; the Postgres 42883 half was
measured in-session and is pinned by nothing. Said so where it is stated.

F4 — the scope-boundary test asserted `isAggregateCompatibleWithFieldType(
'min', 'text') === false`, a verdict #16785 C is about to amend. Dropped: the
case now pins only what this PR owns - a non-temporal field is not judged, so
the measure compiles and SQL is emitted. Refutability is carried by a second
case on `sum` x `text`, a row no ruling is moving, plus a non-vacuity
assertion that SQL reached the driver in both.

F5 — the changeset now names the two uncovered faces: `/analytics/query` and
any `compileDataset` caller wiring no `declaredFieldType` probe.

Refs #16737. Review: PR #16778 contract review, comment 5580295870.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37

Copy link
Copy Markdown
Contributor

Contract review (claude-fable-5-1, isolated seat) — PR #16778 @ 181d3cc8a

Verdict: PASS-conditional. F1–F6 are all discharged on the tree, not only in the body. The code is unchanged from the first review's "ruling implemented: YES"; the breaking declaration now describes exactly what the gate does. The one condition is not a code change: the head carries one red check (Part-of PR must not also close its card) that is a commit-message trailer finding on 181d3cc8a, unrepairable on a pushed branch without a forbidden rewrite, advisory at branch protection, and discharged only at the merge button — see N1. That is the whole of mergeable_state: unstable.

Governed-surface check: docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**none touched (git diff --name-only origin/main...refs/review/16778 | grep -E '^(docs/adr/|\.claude/|skills/|AGENTS\.md|CLAUDE\.md|content/docs/releases/)' → empty). 13 files, all inside the stated surface: 1 changeset, 6 service-analytics/src modules, 4 service-analytics/src/__tests__, 1 ledger entry, 1 generated registry.ts.

Re-verification against the ref (merge-base c930f8597)

  1. The gate (dataset-compiler.ts:297-333, called from the measure loop at :608, after assertDeclared). Order of stand-downs, read from the code: no hook (:303) → no aggregate/field → dotted path (:309) → hook answers undefined (:311) → field not in TEMPORAL_SOURCE_FIELD_TYPES (:314; the set is date/datetime/time, measure-result-type.ts:221-225) → verdict from isAggregateCompatibleWithFieldType (:315), throw datasetInvalidError (:321). The verdict is the spec's: on origin/main AGGREGATE_FIELD_TYPE_COMPATIBILITY (packages/spec/src/data/aggregate-field-type-compatibility.ts:157-169) gives avg = numeric+boolean, sum = additive+boolean, min/max = numeric+temporal+boolean, count/count_distinct = any — so avg/sum × temporal refused, min/max/count × temporal accepted. derived covered by construction: the compile loop throws on the base measure before any expansion; pinned twice, once selecting only cycle_delta (test :311-326). Fail-open, precisely: a dataset on a host with no sourceFieldMeta wired still gets the closure (analytics-service.ts:1138 always passes (o,f) => this.sourceFieldMeta?.(o,f)?.type), the closure answers undefined, :311 returns, the measure compiles to AVG(col) and reaches the driver exactly as before this PR. The :303 tier (no closure at all) is reachable only by a direct compileDataset caller. This IS the ruled posture: the spec module's own header (aggregate-field-type-compatibility.ts:106-109, spec: declare the aggregate × field-type compatibility matrix (AggregationFunction × FieldType) that dataset measures are refused against (spec half of #16099) #16353 under batch Validation Protocol: Cross-Field, Async, and Conditional validation #59) says a consumer that cannot resolve the type "must NOT call the predicate with a guess — 'cannot answer, do not block' is the consumer's tier". The card's PM ruling (service-analytics: AVG() over a Field.datetime measure returns SQLite's text→numeric coercion (an average YEAR) with no error, and derived: { op: 'difference' } renders the difference of two of them as a clean plausible number #16737 comment 5579707565, 裁 A) and [Decision] The ruled aggregate × field-type table cannot be executed in full: its string rows contradict #15768's typing and its boolean rows contradict ruling #11152 — enforce, amend, or leave partly decorative? #16785 C both accept the temporal-only scope. The hosted stack does wire the hook (plugin.ts:746), so a production dataset is judged.
  2. F1. Changeset FROM/TO (.changeset/…refused.md:87-89) carries only the temporal avg/sum rows and the derived row; no percent row. Scope section :61-79 names [Decision] The ruled aggregate × field-type table cannot be executed in full: its string rows contradict #15768's typing and its boolean rows contradict ruling #11152 — enforce, amend, or leave partly decorative? #16785 C (string rows), [Decision] Two maintainer rulings collide on boolean aggregates — batch #59's "every other pair refused" would refuse avg(flag), which ruling #11152 pins on six backends as having no per-aggregate exception #16685/feat(spec): accept boolean / toggle for sum / avg / min / max in the aggregate × field-type table (#16685) #16750 (boolean rows), and says sum×percent is not executed. Ledger surface/replacement/acceptanceCriteria (entries/semantic/18.…refused.ts) are scoped to the temporal class and say a field of any other class is "neither refused nor certified". registry.ts: the added block is the entry's four field texts byte-for-byte, re-indented by the generator's four spaces, inserted at the derived sort position (dataset-measure… < datasource-config…, matching build-migration-registry.ts:253); entries dir 192 files = 192 id: rows in registry.ts; origin/main is 191/191 with no new ledger entry since the merge-base, so the merged tree stays current. Ran here: check-adr-0087-registration.mjs --base origin/main --head refs/review/16778 → exit 0, [BREAKING+bang] registered dataset-measure-aggregate-field-type-refused (new here); check-changeset-no-major.mjs same args → exit 0.
  3. F4. aggregate-datetime-measure-refusal.test.ts:485-503: min×text compiles, no table assertion, sqls.length === 1. :505-518: sum×text asserts isAggregateCompatibleWithFieldType('sum','text') === false (a row [Decision] The ruled aggregate × field-type table cannot be executed in full: its string rows contradict #15768's typing and its boolean rows contradict ruling #11152 — enforce, amend, or leave partly decorative? #16785 C does not move) + sqls.length === 1. :520-531 iterates all three temporal members and expects DATASET_INVALID. Ablation arithmetic checks: 24 it blocks (4+5+3+6+3+3); the refusal-shaped ones (3 refusals + accepted-set message + 2 derived + "every temporal member") are 7 and each uses refusalOf, which throws when nothing is thrown, so none passes vacuously; 17 stay green. No .skip/.only/.todo in the diff.
  4. Annotation sites. Spot-checked analytics-service.ts:487-529 (the single statement), plugin.ts:640-652 and :668-680, native-sql-strategy.ts:963-976; all now link to the one block. The storage claim matches origin/main: SqlDriver.storageDatetimeValue (sql-driver.ts:12999-13002) returns canonicalUtcDatetime(value) on SQLite/Postgres and a MySQL literal on MySQL; needsLegacyDatetimeRepair (:13004-13008) is false unless SQLite ∧ declared datetime ∧ not in canonicalDatetimeFields; registerExternalObject never marks canonical (:13563-13564). Comment-only hunks except the :1138 wiring.
  5. Clause-② / level. DatasetCompileOptions.declaredFieldType (dataset-compiler.ts:174) is a new optional key on a type re-exported from index.ts:18. ⚠️ The same-named declaredFieldType at analytics-service.ts:859 is the pre-existing DatasetScopedStrategyContext hook (strategies/types.ts:72, driver-memory's reference matcher answers $notContains NO for every valued NON-STRING row — the live mingo path answers YES #14079), a different interface — the name is reused, the key is new. minor + **BREAKING** + ! title + one adr-0087 marker: consistent with the no-major gate (exit 0 here; CI Check Changeset and Lint & Repo Gates green on the head carry origin/main's judgeLevel version, which my local checkout predates). Closing keywords: the body has no Part of #N declaration; "implements the compile leg described on No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099" contains no GitHub closing keyword (close/fix/resolve forms only), so nothing binds to No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099. Ran origin/main's check-partof-closing-keyword.mjs on the body's two card sentences → ✓ carries no Part-of/closing-keyword contradiction. check-closing-keyword-parity.mjs locally → exit 3 PREREQUISITE NOT MET (yaml not installed): NOT MEASURED here; CI Lint & Repo Gates green.
  6. CI @ 181d3cc8a: 41 check runs — 33 success, 6 skipped, 2 failure, 0 running. Both failures are the same job, Part-of PR must not also close its card, on the synchronize run (102063579647, 12:36Z) and the edited run (102083572262, 13:36Z). Everything else green, including all six Test Core shards, Temporal Conformance (live PG + MySQL), Spec property liveness, Governed Surface Queue Guard, Check Changeset. Head trails main by 10 commits; none touches any of the 13 files (git diff --stat refs/review/16778...origin/main -- <13 files> → empty).

F1–F6

finding status evidence
F1 (blocking) — declaration overstated the narrowing discharged changeset :61-79, :87-89; ledger surface/acceptanceCriteria scoped to date/datetime/time; registry.ts block = entry text, sorted position, 192 = 192; ADR-0087 gate exit 0
F2 — stale boolean narrative ×3 discharged dataset-compiler.ts scope docblock, test header :44-59, measure-result-type.ts:131-138 all say #16685 A / #16750 ACCEPT and #16785 C; git merge-base --is-ancestor ed7243d52 refs/review/16778 → yes (via merge 31888c210)
F3 (optional) — Postgres half asserted discharged ledger reason: "SQLite half is PINNED by a live sql.js suite … Postgres half was MEASURED IN-SESSION … not pinned by any test"; sql.js is a devDependency (package.json:36)
F4 — boundary test pinned the #16785 C row discharged min×text table assertion gone (:485-503); sum×text + sqls.length === 1 non-vacuity (:505-518)
F5 (optional) — uncovered faces not in changeset discharged changeset :102-107 names /analytics/query and any probe-less compileDataset caller
F6 — CI tree predated #16750 discharged #16750 in ancestry; all CI on the merged head green except N1

New findings

  1. (record) Part-of PR must not also close its card is red on the head for a reason the first review could not see: commit 181d3cc8a's message ends Refs #16737. Review: PR #16778 contract review, comment 5580295870. origin/main's guard (scripts/check-partof-closing-keyword.mjs, the commit-list half reading PR_COMMITS_FILE) refuses any card trailer on a commit — the .claude/agents/os-dev.md rule "commit ⛔ 不带卡片 trailer". Not a body contradiction (item 5). The gate's own text: advisory at branch protection (absent from REQUIRED_CONTEXTS, no merge_group trigger), nothing an author can do on a pushed branch, and what discharges it is a squash whose message is the PR body. I confirmed the gate's premise on main: 0a61db1f5 (the squash of fix(tooling): isolate git children from ambient GIT_*, and make a shared core.bare flip loud #16646) carries Refs #16624 verbatim, i.e. the repo's squash message is assembled from commit messages, not the body. A queue merge of this PR therefore lands Refs #16737 in main's history — a reference only, it moves no card — unless the lander replaces the message by hand.
  2. (observation) Tiering is fail-OPEN on every stand-down (item 1). That is the spec's ruled posture and is stated in the ledger acceptanceCriteria, but note the practical consequence for the two acknowledged faces: /analytics/query and any host that wires no sourceFieldMeta keep producing the average-year number silently. No layer refuses an incoherent aggregate / field-type pair — a dataset measure avg over a datetime works on SQLite and errors on Postgres #16099 owns closing them; nothing here regresses.
  3. (observation) check-closing-keyword-parity NOT MEASURED locally (dependency absent); relied on CI green. check:migration-registry not executed locally either (TS generator, needs the built closure) — the registry block was verified by reading the entry and the generated block side by side plus the count and sort position, and the PR body records exit 0 with an idempotence hash; CI Lint & Repo Gates green.
  4. (observation) avg × Field.time is a genuine dialect split (Postgres answers a clock time). The table refuses it and the gate executes that; already recorded in the body's 验收备注 5 — flagged only so a maintainer sees it before merge, no action asked.

Maintainer-only merge: yes. Not for a governed path (none touched) but for three things only a maintainer seat can do at once: the PR is a draft carrying needs:contract-review; it is a ! BREAKING accept-set narrowing under an ADR-0087 marker (a disposition, not a fix); and N1 means the merge button, not the queue, is where the Refs #16737 residue is either dropped (replace the squash message with the PR body) or accepted into history — a queue merge edits nothing.

Not done by this seat

No approval, no request-changes, no label, no edit, no merge action. Throwaway ref refs/review/16778 deleted; git show-ref confirms it is gone.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

3 participants